fix(json): bound json_encode depth so a cyclic value raises instead of segfaulting (#730) - #757
Merged
Merged
Conversation
…f segfaulting (#730) The decoder has been bounded at JSON_MAX_DEPTH since #495; the encoder walked the Value graph with no counter and no cycle check. A Value graph CAN contain cycles — the cycle collector exists because it can — and building one takes two lines (dict_set of [d, "self", d]) or happens by accident (append of [a, a]). The walk then recursed until the C stack was gone. The crash was uncatchable: try/catch does nothing against a SIGSEGV. print already handled cycles, which is what made json_encode the surprise. Both directions now share one constant, hoisted above the encoder, so a document that decodes always re-encodes. Exceeding it raises a catchable EK_VALUE error rather than emitting truncated JSON — a silent truncation here would be the exact silent-wrong-answer class this audit is closing. Depth rides the C stack as a parameter rather than a thread-local counter like the decoder's g_json_depth. The decoder can use a counter safely because it has no early return between its ++ and --; this encoder signals failure by returning -1 through every frame, which is precisely the shape where a counter gets skewed by a missed decrement — and a skewed counter fails the NEXT call, not the one that broke it. A parameter cannot leak. Reachability beyond json_encode itself: json_path, and shared_set — an HTTP handler storing a self-referential value took the whole server down. eigs_json_encode now returns NULL on refusal and both ext_http.c callers check it; they previously strlen'd the result unconditionally, so returning NULL without touching them would have traded a stack overflow for a NULL deref. shared_incr needed a mutex unlock on its new early return. Tests extend test_json_depth.eigs (3 -> 9 checks), the file that already owns the decoder half: cyclic dict, cyclic list, over-deep non-cyclic, plus a CONTROL that a 151-deep value still encodes and decodes back — without it a bound of 1 would pass every raise-check — and a round-trip proving the two limits agree. Pre-fix the section exits 139 (SIGSEGV); post-fix 0. Measured with -fstack-usage: 80 B/frame, so 200 levels is ~16 KiB above baseline, against the decoder's 96 B/frame at the same limit. Release 3268/3268, asan-http 3363/3363 leak tally 0, embed_stack_soak (64 KiB) PASS. Closes #730 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens JSON encoding to prevent uncatchable process crashes (SIGSEGV) when json_encode is given cyclic or excessively deep values, aligning encoder behavior with the existing decoder depth bound and making failures catchable runtime errors.
Changes:
- Add a shared
JSON_MAX_DEPTHlimit and thread depth as a parameter through the JSON encoder, raisingEK_VALUEon excess depth (including cyclic graphs). - Propagate the encoder’s new “refuse to encode” behavior into HTTP shared-store write paths by handling
eigs_json_encode()returningNULL. - Expand JSON depth tests to cover cyclic structures, over-deep encoding, and decode/encode agreement; update docs and changelog accordingly.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/builtins.c |
Adds depth-bounded JSON encoding with a shared limit and a single error helper; updates json_encode, json_path, and the C-string encoder entry point. |
src/ext_http.c |
Handles eigs_json_encode() returning NULL to avoid crashes in shared_set/shared_incr. |
tests/test_json_depth.eigs |
Extends coverage to cyclic values, over-deep encoding, and a control case under the limit. |
tests/run_all_tests.sh |
Updates the JSON depth section’s reported check count and PASS/FAIL tallies (currently off by one vs asserts). |
docs/BUILTINS.md |
Documents that json_encode/json_decode raise past the shared depth limit and that cycles are rejected. |
CHANGELOG.md |
Records the security fix for #730 in Unreleased notes. |
Comments suppressed due to low confidence (1)
tests/run_all_tests.sh:592
- This FAIL path still increments by 9, but the JSON depth test now has 10 asserts. Update the FAIL tally to keep counts consistent with the PASS/TOTAL side.
echo " FAIL: json-depth (possible crash/regression)"; FAIL=$((FAIL + 9))
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+586
to
+590
| echo "[JSON Depth / DoS guard] (9 checks)" | ||
| JD_OUTPUT=$(./eigenscript ../tests/test_json_depth.eigs 2>&1) | ||
| TOTAL=$((TOTAL + 3)) | ||
| TOTAL=$((TOTAL + 9)) | ||
| if echo "$JD_OUTPUT" | grep -q "All tests passed"; then | ||
| echo " PASS: deep-JSON guard + nested parsing"; PASS=$((PASS + 3)) | ||
| echo " PASS: deep-JSON guard (decode + encode) + nested parsing"; PASS=$((PASS + 9)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #730
The asymmetry
The decoder has been bounded at
JSON_MAX_DEPTHsince #495. The encoder walked the Value graph with no counter and no cycle check — and a Value graph can contain cycles; the cycle collector exists because it can.Pre-fix:
Segmentation fault (core dumped), rc=139. Thecatchis useless —try/catchdoes nothing against a SIGSEGV.printalready handled cycles, which is what madejson_encodethe surprise.append of [a, a]builds one by accident.Both directions now share one constant (hoisted above the encoder rather than duplicated), so a document that decodes always re-encodes. Exceeding it raises a catchable
EK_VALUErather than emitting truncated JSON — silent truncation would be the exact silent-wrong-answer class this audit is closing.Why a parameter, not a counter
The decoder tracks depth in
g_json_depth, a field on the execution state. That is safe there because there is no early return between its++and--. This encoder signals failure by returning-1up through every frame — precisely the shape where a counter gets skewed by one missed decrement. And a skewed counter fails the next call, not the one that broke it. A parameter cannot leak, and needed no new state field or bridge macro.Reachability
Beyond
json_encodeitself:json_path, and — the serious one —shared_set. An HTTP handler storing a self-referential value took the whole server down.eigs_json_encodenow returns NULL on refusal and bothext_http.ccallers check it. They previouslystrlen'd the result unconditionally, so returning NULL without touching them would have traded a stack overflow for a NULL deref.shared_incrneeded a mutex unlock on its new early return.Tests
Extends
test_json_depth.eigs(3 → 9 checks), the file that already owns the decoder half:append of [a, a]) raisesPre-fix the section exits 139 (SIGSEGV); post-fix 0.
Validation
asan-http+detect_leaks=1: 3363/3363, leak tally 0embed_stack_soak.sh(64 KiB rlimit): PASS-fstack-usage: 80 B/frame → ~16 KiB above baseline at the limit, vs the decoder's 96 B/frame at the same depthFollow-up filed separately
Profiling the stack cost turned up something bigger that is not addressed here: under a 64 KiB stack the process SIGSEGVs at ~40–60 source nesting levels, so
PARSE_MAX_DEPTH(256) is unreachable there — the stack dies before the guard can fire. Filing separately rather than changing a limit inside a JSON PR.🤖 Generated with Claude Code